问题背景
随着项目模块日益增多,AppModule 中的 imports 数组不断膨胀。其中一些第三方模块(如邮件模块)并非所有项目都会使用。我们希望实现一种条件式加载机制——通过环境变量控制是否加载某个模块,而不是所有模块都硬编码在代码中。
同理,前面开发的多租户多数据库方案非常复杂,对于小型项目来说完全不需要同时对接 TypeORM、Mongoose、Prisma 三种 ORM。我们需要一种灵活的配置方式,让项目既能运行在简单的单数据库模式下,也能切换到多租户多数据库模式。
第一部分:邮件模块的条件式加载
将 MailModule 改为异步配置
首先把 forRoot 的硬编码配置改为 forRootAsync,通过 ConfigService 读取环境变量:
// .env 文件新增
MAIL_TRANSPORT=smtp://user:pass@smtp.example.com:587
MAIL_FROM="Noreply"
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_ON=true
typescript
// mail.module.ts
import { MailerModule } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
MailerModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
transport: configService.get<string>('MAIL_TRANSPORT'),
defaults: {
from: `"${configService.get('MAIL_FROM')}" <${configService.get('MAIL_FROM_ADDRESS')}>`,
},
}),
}),
],
})
export class MailModule {}
typescript
布尔值转换陷阱
.env 文件中所有值都是字符串,直接使用 Boolean() 转换会出问题——Boolean('false') 返回的是 true,因为任何非空字符串都是 truthy。
解决方案:创建一个 toBoolean 工具函数:
// utils/format.ts
export function toBoolean(value: string | undefined): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const v = value.toLowerCase().trim();
return !(['false', '0', 'no', 'off', ''].includes(v));
}
return false;
}
typescript
在 AppModule 中实现条件加载
由于 ConfigModule 还未加载时无法使用 ConfigService,我们需要在 AppModule 中直接读取 .env 文件:
// app.module.ts
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { toBoolean } from './utils/format';
function getParsedConfig(): Record<string, unknown> {
const envFiles = ['.env', `.env.${process.env.NODE_ENV || 'development'}`];
const parsedConfig: Record<string, unknown> = {};
envFiles.forEach((path) => {
if (fs.existsSync(path)) {
const config = dotenv.parse(fs.readFileSync(path));
Object.assign(parsedConfig, config);
}
});
return parsedConfig;
}
@Module({
imports: [
// 必须加载的核心模块
UserModule,
LoggerModule,
CacheModule,
DatabaseModule,
// 条件式加载的第三方模块
...conditionalImports(),
],
})
export class AppModule {}
function conditionalImports(): Module[] {
const parsedConfig = getParsedConfig();
const imports: Module[] = [];
if (toBoolean(parsedConfig['MAIL_ON'] as string)) {
imports.push(MailModule);
}
return imports;
}
typescript
关键点:.env 与 .env.development 的配置合并,环境特定文件优先级更高。
Optional 装饰器
当 MailModule 未加载时,Controller 中注入的 MailService 会找不到 provider 导致报错。使用 @Optional() 装饰器告诉 NestJS 这个依赖是可选的:
import { Optional } from '@nestjs/common';
@Controller('users')
export class UserController {
constructor(
@Optional()
private mailService: MailService,
) {}
}
typescript
这样当 MAIL_ON=false 时,mailService 为 null,应用不会报错;当 MAIL_ON=true 时,正常初始化并工作。
第二部分:多 ORM 模块的条件式加载
与邮件模块类似,数据库模块也需要条件式加载。核心思路:
- 在
.env中配置TENANT_MODE=true/false和TENANT_DB_TYPE=typeorm,mongoose,prisma - 在
DatabaseModule中读取配置,决定加载哪些 ORM 模块 - 在
UserModule中根据配置动态提供对应的 Repository
这部分将在下一节详细展开。
设计要点总结
| 要点 | 说明 |
|---|---|
forRootAsync | 替代 forRoot,从 ConfigService 读取配置 |
toBoolean | 安全的布尔值转换,避免字符串陷阱 |
| 条件式 imports | 在 AppModule 层面根据配置动态决定加载模块 |
@Optional() | 标记可选依赖,避免模块未加载时报错 |
| 配置合并 | .env + .env.{NODE_ENV} 合并,环境文件优先 |
↑